package org.codefilarete.stalactite.spring.repository.query.bean;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.codefilarete.stalactite.engine.EntityPersister.ExecutableEntityQuery;
import org.codefilarete.stalactite.engine.EntityPersister.ExecutableProjectionQuery;
import org.codefilarete.stalactite.engine.ExecutableQuery;
import org.codefilarete.stalactite.spring.repository.StalactiteRepository;
import org.codefilarete.stalactite.spring.repository.query.BeanQuery;
import org.codefilarete.stalactite.spring.repository.query.StalactiteQueryMethod;
import org.codefilarete.stalactite.sql.Dialect;
import org.codefilarete.tool.Nullable;
import org.codefilarete.tool.Reflections;
import org.codefilarete.tool.VisibleForTesting;
import org.codefilarete.tool.collection.Arrays;
import org.codefilarete.tool.collection.Iterables;
import org.codefilarete.tool.function.Predicates;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.repository.core.NamedQueries;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.query.QueryLookupStrategy;
import org.springframework.data.repository.query.RepositoryQuery;

/**
 * {@link QueryLookupStrategy} that tries to detect a query declared via {@link BeanQuery} annotation.
 *
 * @author Guillaume Mary
 */
public class BeanQueryLookupStrategy<C> implements QueryLookupStrategy {
	
	private final ListableBeanFactory beanFactory;
	private final Dialect dialect;
	
	/**
	 * Creates a new {@link BeanQueryLookupStrategy}.
	 *
	 */
	public BeanQueryLookupStrategy(ListableBeanFactory beanFactory,
								   Dialect dialect) {
		this.beanFactory = beanFactory;
		this.dialect = dialect;
	}
	
	@Override
	public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries) {
		BeanQueryMetadata beanQueryMetadata = findBeanQueryMetadata(method);
		if (beanQueryMetadata != null) {
			StalactiteQueryMethod queryMethod = new StalactiteQueryMethod(method, metadata, factory);
			if (beanQueryMetadata.getBean() instanceof ExecutableEntityQuery) {
				return new BeanRepositoryQuery<>(queryMethod, (ExecutableEntityQuery) beanQueryMetadata.getBean(), beanQueryMetadata.getCounterBean());
			} else {
				return new QueryRepositoryQuery<>(queryMethod, beanQueryMetadata.getBean(), beanQueryMetadata.getCounterBean(), dialect);
			}
		} else {
			return null;
		}
	}
	
	@VisibleForTesting
	@javax.annotation.Nullable
	BeanQueryMetadata findBeanQueryMetadata(Method method) {
		Set<BeanQueryMetadata> queryMetadataSet = collectBeanQueryMetadata(method);
		switch (queryMetadataSet.size()) {
			case 0:
				return null;
			case 1:
				return Iterables.first(queryMetadataSet);
			default:
				// Checking BeanQuery.repositoryClass
				Set<BeanQueryMetadata> defaultBeans = queryMetadataSet.stream()
						.filter(BeanQueryMetadata::isDefault).collect(Collectors.toSet());
				Set<BeanQueryMetadata> dedicatedBeans = queryMetadataSet.stream()
						.filter(metadata -> metadata.isFor((Class<? extends StalactiteRepository>) method.getDeclaringClass())).collect(Collectors.toSet());
				if (dedicatedBeans.size() == 1) {
					return Iterables.first(dedicatedBeans);
				} else if (defaultBeans.size() == 1) {
					return Iterables.first(defaultBeans);
				}
				throw new UnsupportedOperationException("Multiple beans found matching method " + Reflections.toString(method) + ": "
						+ Iterables.collect(queryMetadataSet, metadata -> metadata.beanName, HashSet::new)
						+ ", but none for repository type " + Reflections.toString(method.getDeclaringClass()) + ": "
						+ Iterables.collect(queryMetadataSet, metadata -> Reflections.toString(metadata.queryAnnotation.repositoryClass()), ArrayList::new)
				);
		}
	}
	
	private Set<BeanQueryMetadata> collectBeanQueryMetadata(Method method) {
		// short class dedicated to local algorithm for @Bean + @BeanQuery metadata storage
		Map<String, Object> beansWithAnnotation = beanFactory.getBeansWithAnnotation(BeanQuery.class);
		Set<BeanQueryMetadata> queryMetadataSet = beansWithAnnotation.entrySet().stream()
				.map(entry -> {
					BeanQuery beanQueryAnnotation = beanFactory.findAnnotationOnBean(entry.getKey(), BeanQuery.class);
					Nullable<ExecutableProjectionQuery> counterBean = Nullable.nullable(beanQueryAnnotation).map(BeanQuery::counterBean)
							.filter(Predicates.not(String::isEmpty))
							.map(counterBeanName -> beanFactory.getBean(counterBeanName, ExecutableProjectionQuery.class));
					return new BeanQueryMetadata(
							entry.getKey(),
							(ExecutableQuery) entry.getValue(),
							counterBean.get(),
							beanQueryAnnotation);
				})
				.filter(metadata ->
						metadata.beanName.equals(method.getName())
								|| Arrays.asList(metadata.queryAnnotation.name()).contains(method.getName())
								|| metadata.queryAnnotation.method().equals(method.getName()))
				.collect(Collectors.toSet());
		return queryMetadataSet;
	}
	
	/**
	 * Internal class to store information about @{@link BeanQuery} while looking for it
	 * @author Guillaume Mary
	 */
	@VisibleForTesting
	static class BeanQueryMetadata {
		private final String beanName;
		private final ExecutableQuery bean;
		private final ExecutableProjectionQuery counterBean;
		private final BeanQuery queryAnnotation;
		
		private BeanQueryMetadata(String beanName, ExecutableQuery bean, ExecutableProjectionQuery counterBean, BeanQuery queryAnnotation) {
			this.beanName = beanName;
			this.bean = bean;
			this.counterBean = counterBean;
			this.queryAnnotation = queryAnnotation;
		}
		
		public ExecutableQuery getBean() {
			return bean;
		}
		
		public ExecutableProjectionQuery getCounterBean() {
			return counterBean;
		}
		
		boolean isDefault() {
			return isFor(StalactiteRepository.class);
		}
		
		boolean isFor(Class<? extends StalactiteRepository> repositoryClass) {
			return queryAnnotation.repositoryClass().equals(repositoryClass);
		}
	}
	
	
}
